/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You under the Apache License, Version 2.0
 * (the "License"); you may not use this file except in compliance with
 * the License.  You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package org.apache.hop.core.util;

import java.text.DateFormat;
import java.text.Normalizer;
import java.text.SimpleDateFormat;
import java.util.Collections;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Random;
import org.apache.hop.core.Const;
import org.apache.hop.core.exception.HopValueException;
import org.apache.hop.core.row.IRowMeta;

/** A collection of utilities to manipulate strings. */
public class StringUtil {

  private static final Random random = new Random();

  public static final String UNIX_OPEN = "${";

  public static final String UNIX_CLOSE = "}";

  public static final String WINDOWS_OPEN = "%%";

  public static final String WINDOWS_CLOSE = "%%";

  public static final String HEX_OPEN = "$[";

  public static final String HEX_CLOSE = "]";

  public static final String FIELD_OPEN = "?{";

  public static final String FIELD_CLOSE = "}";

  public static final String CRLF = "\r\n";

  public static final String INDENTCHARS = "                    ";

  public static final String EMPTY_STRING = "";

  protected static final String[] SYSTEM_PROPERTIES =
      new String[] {
        "java.version",
        "java.vendor",
        "java.vendor.url",
        "java.home",
        "java.vm.specification.version",
        "java.vm.specification.vendor",
        "java.vm.specification.name",
        "java.vm.version",
        "java.vm.vendor",
        "java.vm.name",
        "java.specification.version",
        "java.specification.vendor",
        "java.specification.name",
        "java.class.version",
        "java.class.path",
        "java.library.path",
        "java.io.tmpdir",
        "java.compiler",
        "java.ext.dirs",
        "os.name",
        "os.arch",
        "os.version",
        "file.separator",
        "path.separator",
        "line.separator",
        "user.name",
        "user.home",
        "user.dir",
        "user.country",
        "user.language",
        "user.timezone",
        "org.apache.commons.logging.Log",
        "org.apache.commons.logging.simplelog.log.org.apache.http",
        "org.apache.commons.logging.simplelog.showdatetime",
        "org.eclipse.swt.browser.XULRunnerInitialized",
        "org.eclipse.swt.browser.XULRunnerPath",
        "sun.arch.data.model",
        "sun.boot.class.path",
        "sun.boot.library.path",
        "sun.cpu.endian",
        "sun.cpu.isalist",
        "sun.io.unicode.encoding",
        "sun.java.launcher",
        "sun.jnu.encoding",
        "sun.management.compiler",
        "sun.os.patch.level",
      };

  private StringUtil() {}

  /**
   * Substitutes variables in <code>aString</code>. Variable names are delimited by open and close
   * strings. The values are retrieved from the given map.
   *
   * @param aString the string on which to apply the substitution.
   * @param variablesValues a map containing the variable values. The keys are the variable names,
   *     the values are the variable values.
   * @param open the open delimiter for variables.
   * @param close the close delimiter for variables.
   * @return the string with the substitution applied.
   */
  public static String substitute(
      String aString, Map<String, String> variablesValues, String open, String close) {
    return substitute(aString, variablesValues, open, close, 0);
  }

  /**
   * Substitutes variables in <code>aString</code>. Variable names are delimited by open and close
   * strings. The values are retrieved from the given map.
   *
   * @param aString the string on which to apply the substitution.
   * @param variablesValues a map containg the variable values. The keys are the variable names, the
   *     values are the variable values.
   * @param open the open delimiter for variables.
   * @param close the close delimiter for variables.
   * @param recursion the number of recursion (internal counter to avoid endless loops)
   * @return the string with the substitution applied.
   */
  public static String substitute(
      String aString,
      Map<String, String> variablesValues,
      String open,
      String close,
      int recursion) {
    if (aString == null) {
      return null;
    }

    StringBuilder buffer = new StringBuilder();

    String rest = aString;

    // search for opening string
    int i = rest.indexOf(open);
    while (i > -1) {
      int j = rest.indexOf(close, i + open.length());
      // search for closing string
      if (j > -1) {
        String varName = rest.substring(i + open.length(), j);
        Object value = variablesValues.get(varName);
        if (value == null) {
          value = open + varName + close;
        } else {
          // check for another variable inside this value
          int another = ((String) value).indexOf(open); // check
          // here
          // first for
          // speed
          if (another > -1) {
            // for safety: avoid recursive
            if (recursion > 50) {
              // endless loops with stack overflow
              throw new RuntimeException(
                  "Endless loop detected for substitution of variable: " + value);
            }
            value = substitute((String) value, variablesValues, open, close, ++recursion);
          }
        }
        buffer.append(rest.substring(0, i));
        buffer.append(value);
        rest = rest.substring(j + close.length());
      } else {
        // no closing tag found; end the search
        buffer.append(rest);
        rest = "";
      }
      // keep searching
      i = rest.indexOf(open);
    }
    buffer.append(rest);
    return buffer.toString();
  }

  /**
   * Substitutes hex values in <code>aString</code> and convert them to operating system char
   * equivalents in the return string. Format is $[01] or $[6F,FF,00,1F] Example: "This is a hex
   * encoded six digits number 123456 in this string: $[31,32,33,34,35,36]"
   *
   * @param aString the string on which to apply the substitution.
   * @return the string with the substitution applied.
   */
  public static String substituteHex(String aString) {
    if (aString == null) {
      return null;
    }

    StringBuilder buffer = new StringBuilder();

    String rest = aString;

    // search for opening string
    int i = rest.indexOf(HEX_OPEN);
    while (i > -1) {
      int j = rest.indexOf(HEX_CLOSE, i + HEX_OPEN.length());
      // search for closing string
      if (j > -1) {
        buffer.append(rest.substring(0, i));
        String hexString = rest.substring(i + HEX_OPEN.length(), j);
        String[] hexStringArray = hexString.split(",");
        int hexInt;
        byte[] hexByte = new byte[1];
        for (int pos = 0; pos < hexStringArray.length; pos++) {
          try {
            hexInt = Integer.parseInt(hexStringArray[pos], 16);
          } catch (NumberFormatException e) {
            hexInt = 0; // in case we get an invalid hex value, ignore: we can not log here
          }
          hexByte[0] = (byte) hexInt;
          buffer.append(new String(hexByte));
        }
        rest = rest.substring(j + HEX_CLOSE.length());
      } else {
        // no closing tag found; end the search
        buffer.append(rest);
        rest = "";
      }
      // keep searching
      i = rest.indexOf(HEX_OPEN);
    }
    buffer.append(rest);
    return buffer.toString();
  }

  /**
   * Substitutes variables in <code>aString</code> with the environment values in the system
   * properties
   *
   * @param aString the string on which to apply the substitution.
   * @param systemProperties the system properties to use
   * @return the string with the substitution applied.
   */
  public static final synchronized String environmentSubstitute(
      String aString, Map<String, String> systemProperties) {
    Map<String, String> sysMap = new HashMap<>();
    synchronized (sysMap) {
      sysMap.putAll(Collections.synchronizedMap(systemProperties));

      aString = substituteWindows(aString, sysMap);
      aString = substituteUnix(aString, sysMap);
      aString = substituteHex(aString);
      return aString;
    }
  }

  /**
   * Substitutes variables in <code>aString</code>. Variables are of the form "${<variable name>}",
   * following the Unix scripting convention. The values are retrieved from the given map.
   *
   * @param aString the string on which to apply the substitution.
   * @param variables a map containg the variable values. The keys are the variable names, the
   *     values are the variable values.
   * @return the string with the substitution applied.
   */
  public static String substituteUnix(String aString, Map<String, String> variables) {
    return substitute(aString, variables, UNIX_OPEN, UNIX_CLOSE);
  }

  /**
   * Substitutes variables in <code>aString</code>. Variables are of the form "%%<variable name>%%",
   * following the Windows convention. The values are retrieved from the given map.
   *
   * @param aString the string on which to apply the substitution.
   * @param variables a map containg the variable values. The keys are the variable names, the
   *     values are the variable values.
   * @return the string with the substitution applied.
   */
  public static String substituteWindows(String aString, Map<String, String> variables) {
    return substitute(aString, variables, WINDOWS_OPEN, WINDOWS_CLOSE);
  }

  /**
   * Substitutes field values in <code>aString</code>. Field values are of the form "?{<field
   * name>}". The values are retrieved from the specified row. Please note that the getString()
   * method is used to convert to a String, for all values in the row.
   *
   * @param aString the string on which to apply the substitution.
   * @param rowMeta The row metadata to use.
   * @param rowData The row data to use
   * @return the string with the substitution applied.
   * @throws HopValueException In case there is a String conversion error
   */
  public static String substituteField(String aString, IRowMeta rowMeta, Object[] rowData)
      throws HopValueException {
    Map<String, String> variables = new HashMap<>();
    for (int i = 0; i < rowMeta.size(); i++) {
      variables.put(rowMeta.getValueMeta(i).getName(), rowMeta.getString(rowData, i));
    }
    return substitute(aString, variables, FIELD_OPEN, FIELD_CLOSE);
  }

  /**
   * Search the string and report back on the variables used
   *
   * @param aString The string to search
   * @param open the open or "start of variable" characters ${ or %%
   * @param close the close or "end of variable" characters } or %%
   * @param list the list of variables to add to
   * @param includeSystemVariables also check for system variables.
   */
  public static void getUsedVariables(
      String aString,
      String open,
      String close,
      List<String> list,
      boolean includeSystemVariables) {
    if (aString == null) {
      return;
    }

    int p = 0;
    while (p < aString.length()) {
      // OK, we found something... : start of Unix variable
      if (aString.substring(p).startsWith(open)) {
        // See if it's closed...
        int from = p + open.length();
        int to = aString.indexOf(close, from + 1);

        if (to >= 0) {
          String variable = aString.substring(from, to);

          // Either we include the system variables (all)
          // Or the variable is not a system variable
          // Or it's a system variable but the value has not been set (and we offer the user the
          // option to set it)
          //
          if ((Const.indexOfString(variable, list) < 0) && includeSystemVariables
              || !isSystemVariable(variable)
              || System.getProperty(variable) == null) {
            list.add(variable);
          }

          // OK, continue
          p = to + close.length();
        }
      }
      p++;
    }
  }

  public static boolean isSystemVariable(String aString) {
    return Const.indexOfString(aString, SYSTEM_PROPERTIES) >= 0;
  }

  public static void getUsedVariables(
      String aString, List<String> list, boolean includeSystemVariables) {
    getUsedVariables(aString, UNIX_OPEN, UNIX_CLOSE, list, includeSystemVariables);
    getUsedVariables(aString, WINDOWS_OPEN, WINDOWS_CLOSE, list, includeSystemVariables);
  }

  public static String generateRandomString(
      int length, String prefix, String postfix, boolean uppercase) {
    StringBuilder buffer = new StringBuilder();

    if (!Utils.isEmpty(prefix)) {
      buffer.append(prefix);
    }

    for (int i = 0; i < length; i++) {
      int c = 'a' + random.nextInt() * 26;
      buffer.append((char) c);
    }
    if (!Utils.isEmpty(postfix)) {
      buffer.append(postfix);
    }

    if (uppercase) {
      return buffer.toString().toUpperCase();
    }

    return buffer.toString();
  }

  public static String initCap(String st) {
    if (st == null || st.trim().length() == 0) {
      return "";
    }

    if (st.substring(0, 1).equals(st.substring(0, 1).toUpperCase())) {
      // Already initially capitalized.
      return st;
    } else {
      // Capitalize first character
      return st.substring(0, 1).toUpperCase() + st.substring(1);
    }
  }

  /**
   * Check if the string supplied is empty. A String is empty when it is null or when the length is
   * 0
   *
   * @param string The string to check
   * @return true if the string supplied is empty
   */
  public static final boolean isEmpty(String string) {
    return string == null || string.length() == 0;
  }

  /**
   * Check if the StringBuilder supplied is empty. A StringBuilder is empty when it is null or when
   * the length is 0
   *
   * @param string The StringBuilder to check
   * @return true if the StringBuilder supplied is empty
   */
  public static final boolean isEmpty(StringBuilder string) {
    return string == null || string.length() == 0;
  }

  public static String getIndent(int indentLevel) {
    return INDENTCHARS.substring(0, indentLevel);
  }

  /**
   * Giving back a date/time string in the format following the rule from the most to the least
   * significant
   *
   * @param date the date to convert
   * @return a string in the form yyyddMM_hhmmss
   */
  public static String getFormattedDateTime(Date date) {
    return getFormattedDateTime(date, false);
  }

  /**
   * Giving back a date/time string in the format following the rule from the most to the least
   * significant
   *
   * @param date the date to convert
   * @param milliseconds true when milliseconds should be added
   * @return a string in the form yyyddMM_hhmmssSSS (milliseconds will be optional)
   */
  public static String getFormattedDateTime(Date date, boolean milliseconds) {
    DateFormat dateFormat = null;
    if (milliseconds) {
      dateFormat = new SimpleDateFormat(Const.GENERALIZED_DATE_TIME_FORMAT_MILLIS);
    } else {
      dateFormat = new SimpleDateFormat(Const.GENERALIZED_DATE_TIME_FORMAT);
    }
    return dateFormat.format(date);
  }

  /**
   * Giving back the actual time as a date/time string in the format following the rule from the
   * most to the least significant
   *
   * @return a string in the form yyyddMM_hhmmss
   */
  public static String getFormattedDateTimeNow() {
    return getFormattedDateTime(new Date(), false);
  }

  /**
   * Giving back the actual time as a date/time string in the format following the rule from the
   * most to the least significant
   *
   * @param milliseconds true when milliseconds should be added
   * @return a string in the form yyyddMM_hhmmssSSS (milliseconds will be optional)
   */
  public static String getFormattedDateTimeNow(boolean milliseconds) {
    return getFormattedDateTime(new Date(), milliseconds);
  }

  public static boolean isInteger(String str) {
    try {
      Integer.parseInt(str);
    } catch (NumberFormatException e) {
      return false;
    }
    return true;
  }

  public static boolean isNumber(String str) {
    try {
      Double.parseDouble(str);
    } catch (Exception e) {
      return false;
    }
    return true;
  }

  public static boolean isDate(String str) {
    return isDate(str, "yy-mm-dd");
  }

  public static boolean isDate(String str, String mask) {
    // TODO: What about other dates? Maybe something for a CRQ
    try {
      SimpleDateFormat fdate = new SimpleDateFormat(mask);
      fdate.parse(str);
    } catch (Exception e) {
      return false;
    }
    return true;
  }

  /**
   * remove specification from variable
   *
   * @param variable the variable to look for, with the $ or % variable specification.
   * @return the variable name
   */
  public static String getVariableName(String variable) {
    variable = variable.trim();
    if (variable.startsWith(UNIX_OPEN)
        || variable.startsWith(WINDOWS_OPEN)
        || variable.startsWith(HEX_OPEN)) {
      variable = variable.substring(2);
    }
    if (variable.endsWith(UNIX_CLOSE) || variable.endsWith(HEX_CLOSE)) {
      variable = variable.substring(0, variable.length() - 1);
    }
    if (variable.endsWith(WINDOWS_CLOSE)) {
      variable = variable.substring(0, variable.length() - 2);
    }

    return variable;
  }

  /**
   * @param variable the variable to look for, with the $ or % variable specification.
   * @return true if the input is a variable, false otherwise
   */
  public static boolean isVariable(String variable) {
    if (variable == null) {
      return false;
    }
    variable = variable.trim();
    return variable.startsWith(UNIX_OPEN) && variable.endsWith(UNIX_CLOSE)
        || variable.startsWith(WINDOWS_OPEN) && variable.endsWith(WINDOWS_CLOSE)
        || variable.startsWith(HEX_OPEN) && variable.endsWith(HEX_CLOSE);
  }

  /**
   * Calls the {@link String#toLowerCase()} method on the {@link String} returned by a call to
   * {@code obj.toString()}, guarding against {@link NullPointerException}s.
   *
   * @param obj the {@link Object} whose string representation is being turned to lower case
   * @return a lower case {@link String} representation of the {@link String} returned by a call to
   *     {@code obj .toString()}
   */
  public static String safeToLowerCase(final Object obj) {
    if (obj == null || obj.toString() == null) {
      return null;
    } else {
      return obj.toString().toLowerCase();
    }
  }

  /**
   * Removes all instances of the specified character from the start of the given {@link String}.
   *
   * @param source the {@link String} to trim
   * @param c the character to remove from the {@link String}
   * @return a new string with all instances of the specified character removed from the start
   */
  public static String trimStart(final String source, char c) {
    if (source == null) {
      return null;
    }

    int length = source.length();
    int index = 0;

    while (index < length && source.charAt(index) == c) {
      index++;
    }

    return source.substring(index);
  }

  /**
   * Removes all instances of the specified character from the end of the given {@link String}.
   *
   * @param source the {@link String} to trim
   * @param c the character to remove from the {@link String}
   * @return a new string with all instances of the specified character removed from the end
   */
  public static String trimEnd(final String source, char c) {
    if (source == null) {
      return null;
    }

    int index = source.length();

    while (index > 0 && source.charAt(index - 1) == c) {
      index--;
    }

    return source.substring(0, index);
  }

  /**
   * Remove diacritical marks from a string (accents and cedilla) and return the same string withour
   * marks.
   *
   * @param source the {@link String} to clean
   * @return the string without marks
   */
  public static String removeDiacriticalMarks(String source) {
    return Normalizer.normalize(source, Normalizer.Form.NFD)
        .replaceAll("\\p{InCombiningDiacriticalMarks}+", "");
  }
}
